本节代码对应 GitHub 分支: chapter8

仓库传送门 (opens new window)

# 迷你版布局

首先在 Player 目录下新建 miniPlayer 子目录,

//miniPlayer/index.js
import React from 'react';
import {getName} from '../../../api/utils';
import { MiniPlayerContainer } from './style';

function MiniPlayer (props) {
  const { song } = props;
  return (
      <MiniPlayerContainer>
        <div className="icon">
          <div className="imgWrapper">
            <img className="play" src={song.al.picUrl} width="40" height="40" alt="img"/>
          </div>
        </div>
        <div className="text">
          <h2 className="name">{song.name}</h2>
          <p className="desc">{getName (song.ar)}</p>
        </div>
        <div className="control">
          <i className="iconfont">&#xe650;</i>
        </div>
        <div className="control">
          <i className="iconfont">&#xe640;</i>
        </div>
      </MiniPlayerContainer>
  )
}

export default React.memo (MiniPlayer);

样式组件对应如下,在 style.js 中:

import styled, {keyframes} from'styled-components';
import style from '../../../assets/global-style';

const rotate = keyframes`
  0%{
    transform: rotate (0);
  }
  100%{
    transform: rotate (360deg);
  }
`

export const MiniPlayerContainer = styled.div`
  display: flex;
  align-items: center;
  position: fixed;
  left: 0;
  bottom: 0;
  z-index: 1000;
  width: 100%;
  height: 60px;
  background: ${style ["highlight-background-color"]};
  &.mini-enter {
    transform: translate3d (0, 100%, 0);
  }
  &.mini-enter-active {
    transform: translate3d (0, 0, 0);
    transition: all 0.4s;
  }
  &.mini-exit-active {
    transform: translate3d (0, 100%, 0);
    transition: all .4s
  }
  .icon {
    flex: 0 0 40px;
    width: 40px;
    height: 40px;
    padding: 0 10px 0 20px;
    .imgWrapper {
      width: 100%;
      height: 100%;
      img {
        border-radius: 50%;
        &.play {
          animation: ${rotate} 10s infinite;
          &.pause {
            animation-play-state: paused;
          }
        }
      }
    }
  }
  .text {
    display: flex;
    flex-direction: column;
    justify-content: center;
    flex: 1;
    line-height: 20px;
    overflow: hidden;
    .name {
      margin-bottom: 2px;
      font-size: ${style ["font-size-m"]};
      color: ${style ["font-color-desc"]};
      ${style.noWrap ()}
    }
    .desc {
      font-size: ${style ["font-size-s"]};
      color: ${style ["font-color-desc-v2"]};
      ${style.noWrap ()}
    }
  }
  .control {
    flex: 0 0 30px;
    padding: 0 10px;
    .iconfont, .icon-playlist {
      font-size: 30px;
      color: ${style ["theme-color"]};
    }
    .icon-mini {
      font-size: 16px;
      position: absolute;
      left: 8px;
      top: 8px;
      &.icon-play {
        left: 9px
      }
    }
  }
`

当然,在 Player/index.js 下也要做一些修改:

//Player/index.js 修改内容如下
import MiniPlayer from './miniPlayer';

function Player (props) {
  const currentSong = {
    al: { picUrl: "https://p1.music.126.net/JL_id1CFwNJpzgrXwemh4Q==/109951164172892390.jpg" },
    name: "木偶人",
    ar: [{name: "薛之谦"}]
  }
  return (
    <div>
      <MiniPlayer song={currentSong}/>
    </div>
  )
}

//...

现在大家能看到的应该是这个样子了。

img

这里暂停按钮比较单调,因为没有包括进度条,这个组件下一节来开发,现在先用图标代替。

miniPlayer 的布局就这些,还算比较简单,我们现在马上过渡到全屏版本的布局中。

# 全屏版布局

给大家整理了一下,现在大致的布局是这样。

//normalPlayer/index.js
import React from "react";
import {  getName } from "../../../api/utils";
import {
  NormalPlayerContainer,
  Top,
  Middle,
  Bottom,
  Operators,
  CDWrapper,
} from "./style";

function NormalPlayer (props) {
  const {song} =  props;
  return (
    <NormalPlayerContainer>
      <div className="background">
        <img
          src={song.al.picUrl + "?param=300x300"}
          width="100%"
          height="100%"
          alt="歌曲图片"
        />
      </div>
      <div className="background layer"></div>
      <Top className="top">
        <div className="back">
          <i className="iconfont icon-back">&#xe662;</i>
        </div>
        <h1 className="title">{song.name}</h1>
        <h1 className="subtitle">{getName (song.ar)}</h1>
      </Top>
      <Middle>
        <CDWrapper>
          <div className="cd">
            <img
              className="image play"
              src={song.al.picUrl + "?param=400x400"}
              alt=""
            />
          </div>
        </CDWrapper>
      </Middle>
      <Bottom className="bottom">
        <Operators>
          <div className="icon i-left" >
            <i className="iconfont">&#xe625;</i>
          </div>
          <div className="icon i-left">
            <i className="iconfont">&#xe6e1;</i>
          </div>
          <div className="icon i-center">
            <i className="iconfont">&#xe723;</i>
          </div>
          <div className="icon i-right">
            <i className="iconfont">&#xe718;</i>
          </div>
          <div className="icon i-right">
            <i className="iconfont">&#xe640;</i>
          </div>
        </Operators>
      </Bottom>
    </NormalPlayerContainer>
  );
}
export default React.memo (NormalPlayer);

相应的 style.js 如下:

import styled, { keyframes } from "styled-components";
import style from "../../../assets/global-style";

const rotate = keyframes`
  0%{
    transform: rotate (0);
  }
  100%{
    transform: rotate (360deg);
  }
`;
export const NormalPlayerContainer = styled.div`
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 150;
  background: ${style ["background-color"]};
  .background {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    z-index: -1;
    opacity: 0.6;
    filter: blur (20px);
    &.layer {
      background: ${style ["font-color-desc"]};
      opacity: 0.3;
      filter: none;
    }
  }
`;
export const Top = styled.div`
  position: relative;
  margin-bottom: 25px;
  .back {
    position: absolute;
    top: 0;
    left: 6px;
    z-index: 50;
    .iconfont {
      display: block;
      padding: 9px;
      font-size: 24px;
      color: ${style ["font-color-desc"]};
      font-weight: bold;
      transform: rotate (90deg);
    }
  }
  .title {
    width: 70%;
    margin: 0 auto;
    line-height: 40px;
    text-align: center;
    font-size: ${style ["font-size-l"]};
    color: ${style ["font-color-desc"]};
    ${style.noWrap ()};
  }
  .subtitle {
    line-height: 20px;
    text-align: center;
    font-size: ${style ["font-size-m"]};
    color: ${style ["font-color-desc-v2"]};
    ${style.noWrap ()};
  }
`;
export const Middle = styled.div`
  position: fixed;
  width: 100%;
  top: 80px;
  bottom: 170px;
  white-space: nowrap;
  font-size: 0;
  overflow: hidden;
`;
export const CDWrapper = styled.div`
  position: absolute;
  margin: auto;
  top: 10%;
  left: 0;
  right: 0;
  width: 80%;
  box-sizing: border-box;
  height: 80vw;
  .cd {
    width: 100%;
    height: 100%;
    border-radius: 50%;
    .image {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      box-sizing: border-box;
      border-radius: 50%;
      border: 10px solid rgba (255, 255, 255, 0.1);
    }
    .play {
      animation: ${rotate} 20s linear infinite;
      &.pause {
        animation-play-state: paused;
      }
    }
  }
  .playing_lyric {
    margin-top: 20px;
    font-size: 14px;
    line-height: 20px;
    white-space: normal;
    text-align: center;
    color: rgba (255, 255, 255, 0.5);
  }
`;

export const Bottom = styled.div`
  position: absolute;
  bottom: 50px;
  width: 100%;
`;
export const ProgressWrapper = styled.div`
  display: flex;
  align-items: center;
  width: 80%;
  margin: 0px auto;
  padding: 10px 0;
  .time {
    color: ${style ["font-color-desc"]};
    font-size: ${style ["font-size-s"]};
    flex: 0 0 30px;
    line-height: 30px;
    width: 30px;
    &.time-l {
      text-align: left;
    }
    &.time-r {
      text-align: right;
    }
  }
  .progress-bar-wrapper {
    flex: 1;
  }
`;
export const Operators = styled.div`
  display: flex;
  align-items: center;
  .icon {
    font-weight: 300;
    flex: 1;
    color: ${style ["font-color-desc"]};
    &.disable {
      color: ${style ["theme-color-shadow"]};
    }
    i {
      font-weight: 300;
      font-size: 30px;
    }
  }
  .i-left {
    text-align: right;
  }
  .i-center {
    padding: 0 20px;
    text-align: center;
    i {
      font-size: 40px;
    }
  }
  .i-right {
    text-align: left;
  }
  .icon-favorite {
    color: ${style ["theme-color"]};
  }
`;

现在大家可以看到基本的布局啦。如下图,并且唱片部分正在旋转:

img

其实这部分的布局相对之前的几个组件还是相当简单的,不做赘述了,我们把重心放在后面更出彩的部分 ———— 进出场动画。

# 全屏版进场动画

# 引入状态

既然是要进场,那就必须涉及到状态的改变了,具体来说我们现在需要拿出 redux 中的 fullScreen 并做相应的改变。

由于父组件连接了 redux,现在 normalPlayer 只需从父组件接受相应的变量和方法即可。

首先在父组件中传 props:

function Player (props) {
  const { fullScreen } = props;

  const { toggleFullScreenDispatch } = props;

  //...
  return (
    <div>
      <MiniPlayer
        song={currentSong}
        fullScreen={fullScreen}
        toggleFullScreen={toggleFullScreenDispatch}
      />
      <NormalPlayer
        song={currentSong}
        fullScreen={fullScreen}
        toggleFullScreen={toggleFullScreenDispatch}
      />
    </div>
  )
}

然后在 normalPlayer 中接收。

const { song, fullScreen } =  props;
const { toggleFullScreenDispatch } = props;

return (
  <CSSTransition
    classNames="normal"
    in={fullScreen}
    timeout={400}
    mountOnEnter
    //onEnter={enter}
    //onEntered={afterEnter}
    //onExit={leave}
    //onExited={afterLeave}
  >
  // 组件代码
  </CSSTransition>
)

当然,这里的钩子函数还没有定义。因为还有一些准备工作需要提前做一下。

# 准备工作

首先 miniPlayer 里面,当 fullScreen 为 false 的时候应该不显示,我们也可以运用一下 CSSTransition:

// 引入 useRef

const miniPlayerRef = useRef ();

return (
  <CSSTransition
    in={!fullScreen}
    timeout={400}
    classNames="mini"
    onEnter={() => {
      miniPlayerRef.current.style.display = "flex";
    }}
    onExited={() => {
      miniPlayerRef.current.style.display = "none";
    }}
  >
    <MiniPlayerContainer ref={miniPlayerRef} onClick={() => toggleFullScreen (true)}>
      // 其余代码不变
    </MiniPlayerContainer>
  </CSSTransition>
)

关于 mini 动画钩子类在 style.js 中如下声明:

//NormalPlayerContainer 组件下
&.mini-enter {
  transform: translate3d (0, 100%, 0);
}
&.mini-enter-active {
  transform: translate3d (0, 0, 0);
  transition: all 0.4s;
}
&.mini-exit-active {
  transform: translate3d (0, 100%, 0);
  transition: all .4s
}

这样实现了 miniPlayer 进出的过渡效果。

接下来需要用到 JS 的帧动画插件 create-keyframe-animation

npm install create-keyframe-animation --save

# JS 实现帧动画

接下来高能预警!

先拿到一些关键元素的 DOM 对象。

const normalPlayerRef = useRef ();
const cdWrapperRef = useRef ();

分别对应:

<NormalPlayerContainer ref={normalPlayerRef}>
//...
  <Middle ref={cdWrapperRef}>

现在,来开始着手写动画钩子的逻辑。

// 引入的代码
import animations from "create-keyframe-animation";

// 启用帧动画
const enter = () => {
  normalPlayerRef.current.style.display = "block";
  const { x, y, scale } = _getPosAndScale ();// 获取 miniPlayer 图片中心相对 normalPlayer 唱片中心的偏移
  let animation = {
    0: {
      transform: `translate3d (${x} px,${y} px,0) scale (${scale})`
    },
    60: {
      transform: `translate3d (0, 0, 0) scale (1.1)`
    },
    100: {
      transform: `translate3d (0, 0, 0) scale (1)`
    }
  };
  animations.registerAnimation ({
    name: "move",
    animation,
    presets: {
      duration: 400,
      easing: "linear"
    }
  });
  animations.runAnimation (cdWrapperRef.current, "move");
};

// 计算偏移的辅助函数
const _getPosAndScale = () => {
  const targetWidth = 40;
  const paddingLeft = 40;
  const paddingBottom = 30;
  const paddingTop = 80;
  const width = window.innerWidth * 0.8;
  const scale = targetWidth /width;
  // 两个圆心的横坐标距离和纵坐标距离
  const x = -(window.innerWidth/ 2 - paddingLeft);
  const y = window.innerHeight - paddingTop - width / 2 - paddingBottom;
  return {
    x,
    y,
    scale
  };
};
const afterEnter = () => {
  // 进入后解绑帧动画
  const cdWrapperDom = cdWrapperRef.current;
  animations.unregisterAnimation ("move");
  cdWrapperDom.style.animation = "";
};

现在可以看到这样的进场效果。

img

但是,这还不够!

我们可以让 Top 和 Bottom 都跟着动起来。

还记得刚刚写过的 "normal" 的钩子类吗?我们利用贝塞尔动画曲线给它们一个过渡。

//normalPlayer/style.js
//NormalPlayerContainer 样式组件下
&.normal-enter,
&.normal-exit-done {
  .top {
    transform: translate3d (0, -100px, 0);
  }
  .bottom {
    transform: translate3d (0, 100px, 0);
  }
}
&.normal-enter-active,
&.normal-exit-active {
  .top,
  .bottom {
    transform: translate3d (0, 0, 0);
    transition: all 0.4s cubic-bezier (0.86, 0.18, 0.82, 1.32);
  }
  opacity: 1;
  transition: all 0.4s;
}
&.normal-exit-active {
  opacity: 0;
}

仔细观察,Top 和 Bottom 部分出现的相应的过渡,可以发现现在的效果较之前是更加灵动的:

# 出场动画

首先声明一下,我们实现的出场动画是基于 transform 属性的,但是 transform 在不同的浏览器厂商会有不同的前缀,这个问题在 CSS 中可以用 postcss 等工具来解决,但是 JS 中我们现在只有自己来处理了。

在 api/utils.js 中添加:

// 给 css3 相关属性增加浏览器前缀,处理浏览器兼容性问题
let elementStyle = document.createElement ("div").style;

let vendor = (() => {
  // 首先通过 transition 属性判断是何种浏览器
  let transformNames = {
    webkit: "webkitTransform",
    Moz: "MozTransform",
    O: "OTransfrom",
    ms: "msTransform",
    standard: "Transform"
  };
  for (let key in transformNames) {
    if (elementStyle [transformNames [key]] !== undefined) {
      return key;
    }
  }
  return false;
})();

export function prefixStyle (style) {
  if (vendor === false) {
    return false;
  }
  if (vendor === "standard") {
    return style;
  }
  return vendor + style.charAt (0).toUpperCase () + style.substr (1);
}

然后在 normalPlayer/index.js 中引入 prefixStyle 方法。

import { prefixStyle } from "../../../api/utils";

// 组件代码中加入
const transform = prefixStyle ("transform");

接下来写离开动画的逻辑:

const leave = () => {
  if (!cdWrapperRef.current) return;
  const cdWrapperDom = cdWrapperRef.current;
  cdWrapperDom.style.transition = "all 0.4s";
  const { x, y, scale } = _getPosAndScale ();
  cdWrapperDom.style [transform] = `translate3d (${x} px, ${y} px, 0) scale (${scale})`;
};

const afterLeave = () => {
  if (!cdWrapperRef.current) return;
  const cdWrapperDom = cdWrapperRef.current;
  cdWrapperDom.style.transition = "";
  cdWrapperDom.style [transform] = "";
  // 一定要注意现在要把 normalPlayer 这个 DOM 给隐藏掉,因为 CSSTransition 的工作只是把动画执行一遍
  // 不置为 none 现在全屏播放器页面还是存在
  normalPlayerRef.current.style.display = "none";
};

OK, 至此我们的进场和出场动画就开发完成了!是不是 get 到很多新姿势呢:)

阅读全文